/**
 * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */
/**
 * @module link/linkediting
 */
import { Plugin } from 'ckeditor5/src/core';
import { MouseObserver } from 'ckeditor5/src/engine';
import { Input, TwoStepCaretMovement, inlineHighlight, findAttributeRange } from 'ckeditor5/src/typing';
import { ClipboardPipeline } from 'ckeditor5/src/clipboard';
import { keyCodes, env } from 'ckeditor5/src/utils';
import LinkCommand from './linkcommand';
import UnlinkCommand from './unlinkcommand';
import ManualDecorator from './utils/manualdecorator';
import { createLinkElement, ensureSafeUrl, getLocalizedDecorators, normalizeDecorators, openLink, addLinkProtocolIfApplicable } from './utils';
import '../theme/link.css';
const HIGHLIGHT_CLASS = 'ck-link_selected';
const DECORATOR_AUTOMATIC = 'automatic';
const DECORATOR_MANUAL = 'manual';
const EXTERNAL_LINKS_REGEXP = /^(https?:)?\/\//;
/**
 * The link engine feature.
 *
 * It introduces the `linkHref="url"` attribute in the model which renders to the view as a `<a href="url">` element
 * as well as `'link'` and `'unlink'` commands.
 */
export default class LinkEditing extends Plugin {
    /**
     * @inheritDoc
     */
    static get pluginName() {
        return 'LinkEditing';
    }
    /**
     * @inheritDoc
     */
    static get requires() {
        // Clipboard is required for handling cut and paste events while typing over the link.
        return [TwoStepCaretMovement, Input, ClipboardPipeline];
    }
    /**
     * @inheritDoc
     */
    constructor(editor) {
        super(editor);
        editor.config.define('link', {
            addTargetToExternalLinks: false
        });
    }
    /**
     * @inheritDoc
     */
    init() {
        const editor = this.editor;
        // Allow link attribute on all inline nodes.
        editor.model.schema.extend('$text', { allowAttributes: 'linkHref' });
        editor.conversion.for('dataDowncast')
            .attributeToElement({ model: 'linkHref', view: createLinkElement });
        editor.conversion.for('editingDowncast')
            .attributeToElement({ model: 'linkHref', view: (href, conversionApi) => {
                return createLinkElement(ensureSafeUrl(href), conversionApi);
            } });
        editor.conversion.for('upcast')
            .elementToAttribute({
            view: {
                name: 'a',
                attributes: {
                    href: true
                }
            },
            model: {
                key: 'linkHref',
                value: (viewElement) => viewElement.getAttribute('href')
            }
        });
        // Create linking commands.
        editor.commands.add('link', new LinkCommand(editor));
        editor.commands.add('unlink', new UnlinkCommand(editor));
        const linkDecorators = getLocalizedDecorators(editor.t, normalizeDecorators(editor.config.get('link.decorators')));
        this._enableAutomaticDecorators(linkDecorators
            .filter((item) => item.mode === DECORATOR_AUTOMATIC));
        this._enableManualDecorators(linkDecorators
            .filter((item) => item.mode === DECORATOR_MANUAL));
        // Enable two-step caret movement for `linkHref` attribute.
        const twoStepCaretMovementPlugin = editor.plugins.get(TwoStepCaretMovement);
        twoStepCaretMovementPlugin.registerAttribute('linkHref');
        // Setup highlight over selected link.
        inlineHighlight(editor, 'linkHref', 'a', HIGHLIGHT_CLASS);
        // Handle link following by CTRL+click or ALT+ENTER
        this._enableLinkOpen();
        // Change the attributes of the selection in certain situations after the link was inserted into the document.
        this._enableInsertContentSelectionAttributesFixer();
        // Handle a click at the beginning/end of a link element.
        this._enableClickingAfterLink();
        // Handle typing over the link.
        this._enableTypingOverLink();
        // Handle removing the content after the link element.
        this._handleDeleteContentAfterLink();
        // Handle adding default protocol to pasted links.
        this._enableClipboardIntegration();
    }
    /**
     * Processes an array of configured {@link module:link/linkconfig~LinkDecoratorAutomaticDefinition automatic decorators}
     * and registers a {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher downcast dispatcher}
     * for each one of them. Downcast dispatchers are obtained using the
     * {@link module:link/utils/automaticdecorators~AutomaticDecorators#getDispatcher} method.
     *
     * **Note**: This method also activates the automatic external link decorator if enabled with
     * {@link module:link/linkconfig~LinkConfig#addTargetToExternalLinks `config.link.addTargetToExternalLinks`}.
     */
    _enableAutomaticDecorators(automaticDecoratorDefinitions) {
        const editor = this.editor;
        // Store automatic decorators in the command instance as we do the same with manual decorators.
        // Thanks to that, `LinkImageEditing` plugin can re-use the same definitions.
        const command = editor.commands.get('link');
        const automaticDecorators = command.automaticDecorators;
        // Adds a default decorator for external links.
        if (editor.config.get('link.addTargetToExternalLinks')) {
            automaticDecorators.add({
                id: 'linkIsExternal',
                mode: DECORATOR_AUTOMATIC,
                callback: url => !!url && EXTERNAL_LINKS_REGEXP.test(url),
                attributes: {
                    target: '_blank',
                    rel: 'noopener noreferrer'
                }
            });
        }
        automaticDecorators.add(automaticDecoratorDefinitions);
        if (automaticDecorators.length) {
            editor.conversion.for('downcast').add(automaticDecorators.getDispatcher());
        }
    }
    /**
     * Processes an array of configured {@link module:link/linkconfig~LinkDecoratorManualDefinition manual decorators},
     * transforms them into {@link module:link/utils/manualdecorator~ManualDecorator} instances and stores them in the
     * {@link module:link/linkcommand~LinkCommand#manualDecorators} collection (a model for manual decorators state).
     *
     * Also registers an {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement attribute-to-element}
     * converter for each manual decorator and extends the {@link module:engine/model/schema~Schema model's schema}
     * with adequate model attributes.
     */
    _enableManualDecorators(manualDecoratorDefinitions) {
        if (!manualDecoratorDefinitions.length) {
            return;
        }
        const editor = this.editor;
        const command = editor.commands.get('link');
        const manualDecorators = command.manualDecorators;
        manualDecoratorDefinitions.forEach(decoratorDefinition => {
            editor.model.schema.extend('$text', { allowAttributes: decoratorDefinition.id });
            // Keeps reference to manual decorator to decode its name to attributes during downcast.
            const decorator = new ManualDecorator(decoratorDefinition);
            manualDecorators.add(decorator);
            editor.conversion.for('downcast').attributeToElement({
                model: decorator.id,
                view: (manualDecoratorValue, { writer, schema }, { item }) => {
                    // Manual decorators for block links are handled e.g. in LinkImageEditing.
                    if (!(item.is('selection') || schema.isInline(item))) {
                        return;
                    }
                    if (manualDecoratorValue) {
                        const element = writer.createAttributeElement('a', decorator.attributes, { priority: 5 });
                        if (decorator.classes) {
                            writer.addClass(decorator.classes, element);
                        }
                        for (const key in decorator.styles) {
                            writer.setStyle(key, decorator.styles[key], element);
                        }
                        writer.setCustomProperty('link', true, element);
                        return element;
                    }
                }
            });
            editor.conversion.for('upcast').elementToAttribute({
                view: {
                    name: 'a',
                    ...decorator._createPattern()
                },
                model: {
                    key: decorator.id
                }
            });
        });
    }
    /**
     * Attaches handlers for {@link module:engine/view/document~Document#event:enter} and
     * {@link module:engine/view/document~Document#event:click} to enable link following.
     */
    _enableLinkOpen() {
        const editor = this.editor;
        const view = editor.editing.view;
        const viewDocument = view.document;
        this.listenTo(viewDocument, 'click', (evt, data) => {
            const shouldOpen = env.isMac ? data.domEvent.metaKey : data.domEvent.ctrlKey;
            if (!shouldOpen) {
                return;
            }
            let clickedElement = data.domTarget;
            if (clickedElement.tagName.toLowerCase() != 'a') {
                clickedElement = clickedElement.closest('a');
            }
            if (!clickedElement) {
                return;
            }
            const url = clickedElement.getAttribute('href');
            if (!url) {
                return;
            }
            evt.stop();
            data.preventDefault();
            openLink(url);
        }, { context: '$capture' });
        // Open link on Alt+Enter.
        this.listenTo(viewDocument, 'keydown', (evt, data) => {
            const linkCommand = editor.commands.get('link');
            const url = linkCommand.value;
            const shouldOpen = !!url && data.keyCode === keyCodes.enter && data.altKey;
            if (!shouldOpen) {
                return;
            }
            evt.stop();
            openLink(url);
        });
    }
    /**
     * Starts listening to {@link module:engine/model/model~Model#event:insertContent} and corrects the model
     * selection attributes if the selection is at the end of a link after inserting the content.
     *
     * The purpose of this action is to improve the overall UX because the user is no longer "trapped" by the
     * `linkHref` attribute of the selection and they can type a "clean" (`linkHref`–less) text right away.
     *
     * See https://github.com/ckeditor/ckeditor5/issues/6053.
     */
    _enableInsertContentSelectionAttributesFixer() {
        const editor = this.editor;
        const model = editor.model;
        const selection = model.document.selection;
        this.listenTo(model, 'insertContent', () => {
            const nodeBefore = selection.anchor.nodeBefore;
            const nodeAfter = selection.anchor.nodeAfter;
            // NOTE: ↰ and ↱ represent the gravity of the selection.
            // The only truly valid case is:
            //
            //		                                 ↰
            //		...<$text linkHref="foo">INSERTED[]</$text>
            //
            // If the selection is not "trapped" by the `linkHref` attribute after inserting, there's nothing
            // to fix there.
            if (!selection.hasAttribute('linkHref')) {
                return;
            }
            // Filter out the following case where a link with the same href (e.g. <a href="foo">INSERTED</a>) is inserted
            // in the middle of an existing link:
            //
            // Before insertion:
            //		                       ↰
            //		<$text linkHref="foo">l[]ink</$text>
            //
            // Expected after insertion:
            //		                               ↰
            //		<$text linkHref="foo">lINSERTED[]ink</$text>
            //
            if (!nodeBefore) {
                return;
            }
            // Filter out the following case where the selection has the "linkHref" attribute because the
            // gravity is overridden and some text with another attribute (e.g. <b>INSERTED</b>) is inserted:
            //
            // Before insertion:
            //
            //		                       ↱
            //		<$text linkHref="foo">[]link</$text>
            //
            // Expected after insertion:
            //
            //		                                                          ↱
            //		<$text bold="true">INSERTED</$text><$text linkHref="foo">[]link</$text>
            //
            if (!nodeBefore.hasAttribute('linkHref')) {
                return;
            }
            // Filter out the following case where a link is a inserted in the middle (or before) another link
            // (different URLs, so they will not merge). In this (let's say weird) case, we can leave the selection
            // attributes as they are because the user will end up writing in one link or another anyway.
            //
            // Before insertion:
            //
            //		                       ↰
            //		<$text linkHref="foo">l[]ink</$text>
            //
            // Expected after insertion:
            //
            //		                                                             ↰
            //		<$text linkHref="foo">l</$text><$text linkHref="bar">INSERTED[]</$text><$text linkHref="foo">ink</$text>
            //
            if (nodeAfter && nodeAfter.hasAttribute('linkHref')) {
                return;
            }
            model.change(writer => {
                removeLinkAttributesFromSelection(writer, getLinkAttributesAllowedOnText(model.schema));
            });
        }, { priority: 'low' });
    }
    /**
     * Starts listening to {@link module:engine/view/document~Document#event:mousedown} and
     * {@link module:engine/view/document~Document#event:selectionChange} and puts the selection before/after a link node
     * if clicked at the beginning/ending of the link.
     *
     * The purpose of this action is to allow typing around the link node directly after a click.
     *
     * See https://github.com/ckeditor/ckeditor5/issues/1016.
     */
    _enableClickingAfterLink() {
        const editor = this.editor;
        const model = editor.model;
        editor.editing.view.addObserver(MouseObserver);
        let clicked = false;
        // Detect the click.
        this.listenTo(editor.editing.view.document, 'mousedown', () => {
            clicked = true;
        });
        // When the selection has changed...
        this.listenTo(editor.editing.view.document, 'selectionChange', () => {
            if (!clicked) {
                return;
            }
            // ...and it was caused by the click...
            clicked = false;
            const selection = model.document.selection;
            // ...and no text is selected...
            if (!selection.isCollapsed) {
                return;
            }
            // ...and clicked text is the link...
            if (!selection.hasAttribute('linkHref')) {
                return;
            }
            const position = selection.getFirstPosition();
            const linkRange = findAttributeRange(position, 'linkHref', selection.getAttribute('linkHref'), model);
            // ...check whether clicked start/end boundary of the link.
            // If so, remove the `linkHref` attribute.
            if (position.isTouching(linkRange.start) || position.isTouching(linkRange.end)) {
                model.change(writer => {
                    removeLinkAttributesFromSelection(writer, getLinkAttributesAllowedOnText(model.schema));
                });
            }
        });
    }
    /**
     * Starts listening to {@link module:engine/model/model~Model#deleteContent} and {@link module:engine/model/model~Model#insertContent}
     * and checks whether typing over the link. If so, attributes of removed text are preserved and applied to the inserted text.
     *
     * The purpose of this action is to allow modifying a text without loosing the `linkHref` attribute (and other).
     *
     * See https://github.com/ckeditor/ckeditor5/issues/4762.
     */
    _enableTypingOverLink() {
        const editor = this.editor;
        const view = editor.editing.view;
        // Selection attributes when started typing over the link.
        let selectionAttributes = null;
        // Whether pressed `Backspace` or `Delete`. If so, attributes should not be preserved.
        let deletedContent = false;
        // Detect pressing `Backspace` / `Delete`.
        this.listenTo(view.document, 'delete', () => {
            deletedContent = true;
        }, { priority: 'high' });
        // Listening to `model#deleteContent` allows detecting whether selected content was a link.
        // If so, before removing the element, we will copy its attributes.
        this.listenTo(editor.model, 'deleteContent', () => {
            const selection = editor.model.document.selection;
            // Copy attributes only if anything is selected.
            if (selection.isCollapsed) {
                return;
            }
            // When the content was deleted, do not preserve attributes.
            if (deletedContent) {
                deletedContent = false;
                return;
            }
            // Enabled only when typing.
            if (!isTyping(editor)) {
                return;
            }
            if (shouldCopyAttributes(editor.model)) {
                selectionAttributes = selection.getAttributes();
            }
        }, { priority: 'high' });
        // Listening to `model#insertContent` allows detecting the content insertion.
        // We want to apply attributes that were removed while typing over the link.
        this.listenTo(editor.model, 'insertContent', (evt, [element]) => {
            deletedContent = false;
            // Enabled only when typing.
            if (!isTyping(editor)) {
                return;
            }
            if (!selectionAttributes) {
                return;
            }
            editor.model.change(writer => {
                for (const [attribute, value] of selectionAttributes) {
                    writer.setAttribute(attribute, value, element);
                }
            });
            selectionAttributes = null;
        }, { priority: 'high' });
    }
    /**
     * Starts listening to {@link module:engine/model/model~Model#deleteContent} and checks whether
     * removing a content right after the "linkHref" attribute.
     *
     * If so, the selection should not preserve the `linkHref` attribute. However, if
     * the {@link module:typing/twostepcaretmovement~TwoStepCaretMovement} plugin is active and
     * the selection has the "linkHref" attribute due to overriden gravity (at the end), the `linkHref` attribute should stay untouched.
     *
     * The purpose of this action is to allow removing the link text and keep the selection outside the link.
     *
     * See https://github.com/ckeditor/ckeditor5/issues/7521.
     */
    _handleDeleteContentAfterLink() {
        const editor = this.editor;
        const model = editor.model;
        const selection = model.document.selection;
        const view = editor.editing.view;
        // A flag whether attributes `linkHref` attribute should be preserved.
        let shouldPreserveAttributes = false;
        // A flag whether the `Backspace` key was pressed.
        let hasBackspacePressed = false;
        // Detect pressing `Backspace`.
        this.listenTo(view.document, 'delete', (evt, data) => {
            hasBackspacePressed = data.direction === 'backward';
        }, { priority: 'high' });
        // Before removing the content, check whether the selection is inside a link or at the end of link but with 2-SCM enabled.
        // If so, we want to preserve link attributes.
        this.listenTo(model, 'deleteContent', () => {
            // Reset the state.
            shouldPreserveAttributes = false;
            const position = selection.getFirstPosition();
            const linkHref = selection.getAttribute('linkHref');
            if (!linkHref) {
                return;
            }
            const linkRange = findAttributeRange(position, 'linkHref', linkHref, model);
            // Preserve `linkHref` attribute if the selection is in the middle of the link or
            // the selection is at the end of the link and 2-SCM is activated.
            shouldPreserveAttributes = linkRange.containsPosition(position) || linkRange.end.isEqual(position);
        }, { priority: 'high' });
        // After removing the content, check whether the current selection should preserve the `linkHref` attribute.
        this.listenTo(model, 'deleteContent', () => {
            // If didn't press `Backspace`.
            if (!hasBackspacePressed) {
                return;
            }
            hasBackspacePressed = false;
            // Disable the mechanism if inside a link (`<$text url="foo">F[]oo</$text>` or <$text url="foo">Foo[]</$text>`).
            if (shouldPreserveAttributes) {
                return;
            }
            // Use `model.enqueueChange()` in order to execute the callback at the end of the changes process.
            editor.model.enqueueChange(writer => {
                removeLinkAttributesFromSelection(writer, getLinkAttributesAllowedOnText(model.schema));
            });
        }, { priority: 'low' });
    }
    /**
     * Enables URL fixing on pasting.
     */
    _enableClipboardIntegration() {
        const editor = this.editor;
        const model = editor.model;
        const defaultProtocol = this.editor.config.get('link.defaultProtocol');
        if (!defaultProtocol) {
            return;
        }
        this.listenTo(editor.plugins.get('ClipboardPipeline'), 'contentInsertion', (evt, data) => {
            model.change(writer => {
                const range = writer.createRangeIn(data.content);
                for (const item of range.getItems()) {
                    if (item.hasAttribute('linkHref')) {
                        const newLink = addLinkProtocolIfApplicable(item.getAttribute('linkHref'), defaultProtocol);
                        writer.setAttribute('linkHref', newLink, item);
                    }
                }
            });
        });
    }
}
/**
 * Make the selection free of link-related model attributes.
 * All link-related model attributes start with "link". That includes not only "linkHref"
 * but also all decorator attributes (they have dynamic names), or even custom plugins.
 */
function removeLinkAttributesFromSelection(writer, linkAttributes) {
    writer.removeSelectionAttribute('linkHref');
    for (const attribute of linkAttributes) {
        writer.removeSelectionAttribute(attribute);
    }
}
/**
 * Checks whether selection's attributes should be copied to the new inserted text.
 */
function shouldCopyAttributes(model) {
    const selection = model.document.selection;
    const firstPosition = selection.getFirstPosition();
    const lastPosition = selection.getLastPosition();
    const nodeAtFirstPosition = firstPosition.nodeAfter;
    // The text link node does not exist...
    if (!nodeAtFirstPosition) {
        return false;
    }
    // ...or it isn't the text node...
    if (!nodeAtFirstPosition.is('$text')) {
        return false;
    }
    // ...or isn't the link.
    if (!nodeAtFirstPosition.hasAttribute('linkHref')) {
        return false;
    }
    // `textNode` = the position is inside the link element.
    // `nodeBefore` = the position is at the end of the link element.
    const nodeAtLastPosition = lastPosition.textNode || lastPosition.nodeBefore;
    // If both references the same node selection contains a single text node.
    if (nodeAtFirstPosition === nodeAtLastPosition) {
        return true;
    }
    // If nodes are not equal, maybe the link nodes has defined additional attributes inside.
    // First, we need to find the entire link range.
    const linkRange = findAttributeRange(firstPosition, 'linkHref', nodeAtFirstPosition.getAttribute('linkHref'), model);
    // Then we can check whether selected range is inside the found link range. If so, attributes should be preserved.
    return linkRange.containsRange(model.createRange(firstPosition, lastPosition), true);
}
/**
 * Checks whether provided changes were caused by typing.
 */
function isTyping(editor) {
    const currentBatch = editor.model.change(writer => writer.batch);
    return currentBatch.isTyping;
}
/**
 * Returns an array containing names of the attributes allowed on `$text` that describes the link item.
 */
function getLinkAttributesAllowedOnText(schema) {
    const textAttributes = schema.getDefinition('$text').allowAttributes;
    return textAttributes.filter(attribute => attribute.startsWith('link'));
}
